Next.js 기반의 대규모 모노레포에서 React Query 레이어를 구축하여 props drilling을 제거하고 SSR/CSR 환경을 통합 처리한 경험을 공유합니다. Provider level에서 하이드레이트 대응으로 사용처의 react-query 디펜던시를 제거했습니다.

📊 프로젝트 개요

배경

  • 프레임워크: Next.js 14, React 18
  • 목표: props drilling 제거 및 SSR/CSR 환경 통합 처리
  • 성과: 사용처 react-query 디펜던시 제거, 모듈 독립성 확보

핵심 성과

  • ✅ Provider level에서 하이드레이트 대응
  • ✅ prefetch/query/useQuery를 이용한 props drilling 제거
  • ✅ 쿼리 키와 queryFn을 함께 묶어서 사용하여 캐시 미스 방지
  • ✅ 사용처 react-query 디펜던시 제거
  • ✅ SSR에 컴포넌트를 유연하게 대응
  • ✅ 모듈 독립성 확보

🔴 문제 상황

1. Props Drilling 문제

문제:

  • 데이터를 여러 컴포넌트 레벨을 거쳐 전달해야 함
  • 중간 컴포넌트들이 불필요한 props를 받아야 함
  • 코드 가독성 저하 및 유지보수 어려움

BOLD_PAREN_PLACEHOLDER_0:

// 페이지 컴포넌트
export default function HomePage({ initialData }) {
  return (
    <Layout>
      <Header userData={initialData.user} />
      <MainContent
        products={initialData.products}
        userData={initialData.user}
      />
      <Footer />
    </Layout>
  )
}

// 중간 컴포넌트
function MainContent({ products, userData }) {
  return (
    <div>
      <ProductList products={products} userData={userData} />
      <RecommendationSection userData={userData} />
    </div>
  )
}

// 실제 사용 컴포넌트
function ProductList({ products, userData }) {
  // userData를 사용하지 않지만 props로 받아야 함
  return <div>{/* products만 사용 */}</div>
}

문제점:

  • userData를 사용하지 않는 컴포넌트도 props로 받아야 함
  • 중간 컴포넌트들이 불필요한 props 전달 역할만 함
  • 타입 정의가 복잡해짐

2. SSR/CSR 환경 분리 문제

문제:

  • SSR 환경에서는 getStaticProps에서 데이터를 가져와야 함
  • CSR 환경에서는 useQuery를 사용해야 함
  • 같은 데이터를 두 가지 방식으로 처리해야 함

BOLD_PAREN_PLACEHOLDER_1:

// SSR 환경
export async function getStaticProps() {
  const products = await fetchProducts()
  const user = await fetchUser()

  return {
    props: {
      initialData: { products, user },
    },
  }
}

// CSR 환경
function Component() {
  const { data: products } = useQuery('products', fetchProducts)
  const { data: user } = useQuery('user', fetchUser)

  // 같은 로직을 두 번 작성해야 함
}

문제점:

  • 같은 데이터를 두 가지 방식으로 처리
  • 코드 중복 발생
  • 환경 전환 시 수정 범위 큼

3. 사용처 react-query 디펜던시 문제

문제:

  • 모듈을 사용하는 곳에서 react-query를 직접 import해야 함
  • 모듈과 사용처 간 결합도 증가
  • 모듈 독립성 저하

BOLD_PAREN_PLACEHOLDER_2:

// 모듈 사용처
import { useQuery } from '@tanstack/react-query'
import { ProductList } from '@modules/product-list'

function Page() {
  const { data: products } = useQuery(['products'], fetchProducts)

  return <ProductList products={products} />
}

문제점:

  • 사용처에서 react-query를 알아야 함
  • 모듈과 사용처 간 결합도 높음
  • 모듈 교체 시 사용처도 수정 필요

✅ 해결 방법

1. Provider Level에서 하이드레이트 대응

핵심 아이디어:

  • Provider level에서 SSR 데이터를 React Query 캐시에 하이드레이트
  • 사용처에서는 환경을 신경 쓰지 않고 useQuery만 사용

구현:

// CorePackProvider.tsx
import {
  QueryClient,
  QueryClientProvider,
  dehydrate,
  Hydrate,
} from '@tanstack/react-query'

export function CorePackProvider({
  children,
  dehydratedState,
}: CorePackProviderProps) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1분
            cacheTime: 5 * 60 * 1000, // 5분
          },
        },
      })
  )

  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={dehydratedState}>{children}</Hydrate>
    </QueryClientProvider>
  )
}

페이지 레벨에서 사용:

// pages/home.tsx
export async function getStaticProps() {
  const queryClient = new QueryClient()

  // SSR에서 데이터 prefetch
  await queryClient.prefetchQuery(['products'], fetchProducts)
  await queryClient.prefetchQuery(['user'], fetchUser)

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  }
}

export default function HomePage({ dehydratedState }) {
  return (
    <CorePackProvider dehydratedState={dehydratedState}>
      <HomePageContent />
    </CorePackProvider>
  )
}

효과:

  • ✅ SSR 데이터가 React Query 캐시에 하이드레이트됨
  • ✅ CSR 환경에서도 동일한 방식으로 접근 가능
  • ✅ 환경 전환 시 코드 수정 불필요

2. 쿼리 키와 함수 관리 → prefetch/useQuery에서 사용

핵심 원칙: 쿼리 키와 queryFn을 함께 묶어서 prefetch와 useQuery에서 동일하게 사용하면 캐시 미스가 발생하지 않습니다.

구조:

  1. 쿼리 키와 함수 관리: 쿼리 객체로 묶어서 정의
  2. prefetch/useQuery에서 사용: 정의한 쿼리 객체를 재사용

1단계: 쿼리 키와 함수 관리

쿼리 객체로 묶기 (queries/products.ts):

// queries/products.ts
import { QueryOptions } from '@tanstack/react-query'

export const productsQuery = {
  queryKey: ['products'] as const,
  queryFn: fetchProducts,
} satisfies QueryOptions

export const productDetailQuery = (id: number) =>
  ({
    queryKey: ['products', 'detail', id] as const,
    queryFn: () => fetchProductDetail(id),
  } satisfies QueryOptions)

export const productsListQuery = (filters?: string) =>
  ({
    queryKey: ['products', 'list', { filters }] as const,
    queryFn: () => fetchProducts(filters),
  } satisfies QueryOptions)

효과:

  • ✅ prefetch와 useQuery에서 동일한 객체 사용으로 캐시 미스 방지
  • ✅ 쿼리 키와 queryFn이 항상 일치 보장
  • ✅ 타입 안전성 확보
  • ✅ 쿼리 정의 변경 시 한 곳만 수정

2단계: prefetch와 useQuery에서 사용

BOLD_PAREN_PLACEHOLDER_3:

// hooks/useProductsPrefetch.ts
import { QueryClient } from '@tanstack/react-query'
import {
  productsQuery,
  productDetailQuery,
  productsListQuery,
} from '../queries/products'

export async function prefetchProducts(
  queryClient: QueryClient,
  filters?: string
) {
  // prefetch에서도 동일한 쿼리 객체 사용
  return queryClient.prefetchQuery({
    ...productsListQuery(filters),
    staleTime: 60 * 1000,
  })
}

export async function prefetchProductDetail(
  queryClient: QueryClient,
  id: number
) {
  // prefetch에서도 동일한 쿼리 객체 사용
  return queryClient.prefetchQuery({
    ...productDetailQuery(id),
    staleTime: 60 * 1000,
  })
}

BOLD_PAREN_PLACEHOLDER_4:

// hooks/useProductsQuery.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import {
  productsQuery,
  productDetailQuery,
  productsListQuery,
} from '../queries/products'

export function useProductsQuery(
  filters?: string,
  options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
  // useQuery에서도 동일한 쿼리 객체 사용 → 캐시 미스 방지!
  return useQuery({
    ...productsListQuery(filters),
    staleTime: 60 * 1000,
    ...options,
  })
}

export function useProductDetailQuery(
  id: number,
  options?: Omit<UseQueryOptions, 'queryKey' | 'queryFn'>
) {
  // useQuery에서도 동일한 쿼리 객체 사용 → 캐시 미스 방지!
  return useQuery({
    ...productDetailQuery(id),
    staleTime: 60 * 1000,
    ...options,
  })
}

컴포넌트에서 사용하는 hook:

// hooks/useProducts.ts
import { useProductsQuery, useProductDetailQuery } from './useProductsQuery'

export function useProducts(filters?: string) {
  const { data, isLoading, error } = useProductsQuery(filters)

  return {
    products: data ?? [],
    isLoading,
    error,
  }
}

export function useProduct(id: number) {
  const { data, isLoading, error } = useProductDetailQuery(id)

  return {
    product: data,
    isLoading,
    error,
  }
}

사용 예시:

// 컴포넌트에서 사용
function ProductList({ filters }: { filters?: string }) {
  const { products, isLoading } = useProducts(filters)

  if (isLoading) return <Loading />

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

function ProductDetail({ id }: { id: number }) {
  const { product, isLoading } = useProduct(id)

  if (isLoading) return <Loading />
  if (!product) return <NotFound />

  return <ProductDetailView product={product} />
}

효과:

  • ✅ props drilling 제거
  • ✅ 컴포넌트가 필요한 데이터를 직접 가져옴
  • ✅ 중간 컴포넌트는 props 전달 불필요
  • prefetch와 useQuery에서 동일한 쿼리 객체 사용으로 캐시 미스 방지
  • ✅ 쿼리 키와 queryFn이 항상 일치하여 캐시 히트 보장

3. 모듈 독립성 확보

BOLD_PAREN_PLACEHOLDER_5:

// 사용처
import { useQuery } from '@tanstack/react-query'
import { ProductList } from '@modules/product-list'

function Page() {
  const { data: products } = useQuery(['products'], fetchProducts)
  return <ProductList products={products} />
}

BOLD_PAREN_PLACEHOLDER_6:

// 모듈 내부 (ProductList/index.tsx)
import { useProducts } from './hooks/useProducts'

export function ProductList() {
  const { products, isLoading } = useProducts()

  if (isLoading) return <Loading />

  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  )
}

// 사용처
import { ProductList } from '@modules/product-list'

function Page() {
  // react-query를 직접 사용하지 않음!
  return <ProductList />
}

효과:

  • ✅ 사용처에서 react-query 디펜던시 제거
  • ✅ 모듈 독립성 확보
  • ✅ 모듈 교체 시 사용처 수정 불필요

4. SSR/CSR 통합 처리

통합된 데이터 페칭 로직:

// hooks/useProducts.ts
import { useProductsQuery } from './useProductsQuery'

export function useProducts(filters?: string) {
  // SSR에서 prefetch된 데이터가 있으면 사용
  // 없으면 CSR에서 fetch
  // prefetch와 동일한 쿼리 객체 사용으로 캐시 히트 보장
  const { data, isLoading, error } = useProductsQuery(filters)

  return {
    products: data ?? [],
    isLoading,
    error,
  }
}

페이지 레벨에서 prefetch:

// pages/home.tsx
import { QueryClient } from '@tanstack/react-query'
import { dehydrate } from '@tanstack/react-query'
import { prefetchProducts } from '../hooks/useProductsPrefetch'
import { prefetchUserProfile } from '../hooks/useUserPrefetch'

export async function getStaticProps() {
  const queryClient = new QueryClient()

  // SSR에서 prefetch (중앙 관리된 쿼리 키와 함수 사용)
  await prefetchProducts(queryClient)
  await prefetchUserProfile(queryClient)

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
    revalidate: 60, // ISR
  }
}

효과:

  • ✅ SSR과 CSR에서 동일한 코드 사용
  • ✅ 환경 전환 시 코드 수정 불필요
  • ✅ ISR과도 자연스럽게 통합

🏗️ 아키텍처

변경 전 구조

Page Component
  ↓ (props drilling)
Layout Component
  ↓ (props drilling)
MainContent Component
  ↓ (props drilling)
ProductList Component (실제 사용)

변경 후 구조

1. 쿼리 키와 함수 관리
   queries/products.ts
   └─ productsQuery (queryKey + queryFn 묶음)

2. prefetch/useQuery에서 사용
   Page Component
     ↓ (prefetch - 동일한 쿼리 객체 사용)
   CorePackProvider (하이드레이트)
   ProductList Component
     ↓ (useQuery - 동일한 쿼리 객체 사용)
   React Query Cache (캐시 히트 보장!)

📈 개선 효과

항목 Before After 개선
Props 전달 레벨 3-4단계 0단계 100% 제거
중간 컴포넌트 수정 필요 불필요 수정 범위 감소
코드 가독성 낮음 높음 향상
모듈 독립성 낮음 높음 향상
환경 전환 코드 수정 필요 수정 불필요 유연성 향상

💡 핵심 교훈

1. Provider Level에서 하이드레이트가 핵심

SSR 데이터를 React Query 캐시에 하이드레이트하면, CSR 환경에서도 동일한 방식으로 접근할 수 있습니다. 이는 환경 전환 시 코드 수정을 최소화하는 핵심입니다.

2. 쿼리 키와 queryFn을 함께 묶어서 사용해야 함

핵심: prefetch와 useQuery에서 동일한 쿼리 객체(queryKey + queryFn)를 사용해야 캐시 미스가 발생하지 않습니다.

문제 상황:

// ❌ 잘못된 예: prefetch와 useQuery에서 다른 방식으로 정의
// prefetch에서
await queryClient.prefetchQuery({
  queryKey: ['products'],
  queryFn: fetchProducts,
})

// useQuery에서 (쿼리 키나 함수가 조금이라도 다르면 캐시 미스!)
const { data } = useQuery({
  queryKey: ['products'], // 동일하지만
  queryFn: () => fetchProducts(), // 화살표 함수로 감싸면 다른 함수로 인식!
})

해결 방법:

// ✅ 올바른 예: 쿼리 객체를 묶어서 재사용
const productsQuery = {
  queryKey: ['products'],
  queryFn: fetchProducts,
}

// prefetch에서
await queryClient.prefetchQuery(productsQuery)

// useQuery에서 (동일한 객체 사용)
const { data } = useQuery(productsQuery)

이렇게 하면 prefetch에서 준비한 데이터를 useQuery에서 정확히 찾을 수 있어 캐시 히트가 보장됩니다.

3. 쿼리 키와 함수를 먼저 관리하고 prefetch/useQuery에서 사용하는 구조가 효과적

쿼리 키와 queryFn을 먼저 묶어서 관리하고, prefetch와 useQuery에서 동일한 쿼리 객체를 사용하면 캐시 일관성이 보장됩니다. 이렇게 하면 각 레이어의 책임이 명확해지고 재사용성이 높아집니다.

4. 모듈 독립성을 위해 내부에서 처리

모듈이 필요한 데이터를 내부에서 가져오도록 하면, 사용처의 디펜던시를 제거할 수 있고 모듈 독립성이 향상됩니다.

5. 타입 안전성을 유지해야 함

React Query의 타입을 활용하여 타입 안전성을 유지하는 것이 중요합니다. useQuery의 제네릭 타입을 명시적으로 지정하고, 쿼리 키를 as const로 정의하면 타입 체크가 강화됩니다.

6. 캐싱 전략을 신중하게 설계해야 함

staleTime, cacheTime 등을 적절히 설정하여 불필요한 API 호출을 방지하고, 사용자 경험을 향상시켜야 합니다. prefetch와 useQuery에서 동일한 쿼리 객체를 사용하면 캐시 히트가 보장되어 SSR 데이터를 효율적으로 활용할 수 있습니다.


🎯 결론

React Query 레이어 구축을 통해 다음과 같은 성과를 달성했습니다:

  1. Props Drilling 완전 제거: 컴포넌트가 필요한 데이터를 직접 가져옴
  2. 캐시 미스 방지: prefetch와 useQuery에서 동일한 쿼리 객체 사용으로 캐시 히트 보장
  3. SSR/CSR 환경 통합: 동일한 코드로 두 환경 모두 지원
  4. 모듈 독립성 확보: 사용처 react-query 디펜던시 제거
  5. 코드 가독성 향상: 중간 컴포넌트의 불필요한 props 전달 제거
  6. 유지보수성 향상: 환경 전환 시 코드 수정 최소화, 쿼리 객체 변경 시 한 곳만 수정

이번 작업을 통해 대규모 모노레포에서의 상태 관리와 데이터 페칭 아키텍처 설계 경험을 쌓았고, React Query의 prefetch, 하이드레이트, 캐싱 전략, 쿼리 키와 queryFn을 함께 묶어서 사용하는 패턴 등을 실무에 적용할 수 있었습니다.


📚 참고 자료